feat: audio attachment support for audio-capable models (Gemini) - #13
feat: audio attachment support for audio-capable models (Gemini)#13wolframs wants to merge 6 commits into
Conversation
Mirrors the image pipeline for audio/* Discord attachments, gated behind a new per-bot include_audio/max_audio config (default off). Audio-capable bots fetch audio attachments (size-capped, MIME-normalized) and send them inline as base64 AudioContent blocks. - connector: detect audio/* attachments, fetch + in-memory cache, reject oversized uploads pre-fetch (MAX_AUDIO_BYTES), normalize audio/mpeg -> audio/mp3 - types: AudioContent block, CachedAudio, DiscordContext.audios, BotConfig include_audio/max_audio - format-messages: emit AudioContent gated by include_audio, bounded by a combined images+audio inline budget (Gemini ~20MB ceiling) - adapter: AudioContent -> membrane audio block - provider: redact base64 audio from request logs (parity with images) - loop: only audio-capable bots fetch audio (maxAudio threading) Requires membrane Gemini audio support (antra-tess/membrane#29) to reach the API; until the dep is bumped, audio blocks are dropped harmlessly by the provider. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- config/bots/claude.yaml.example: document include_audio/max_audio (off by default; for audio-capable models like Gemini 3.5 Flash) - connector: Discord's content_type is optional, so detect audio by content_type OR filename extension (isAudioAttachment/audioMimeFor), mirroring the text attachment fallback; skip when the type can't be resolved - capture attachment duration (voice messages) into CachedAudio/AudioContent Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The connector detects audio by content_type OR filename extension, but
format-messages still gated emission on content_type?.startsWith('audio/') —
so extension-detected audio (Discord omits content_type) was fetched but never
emitted. Trust audioMap membership instead (connector already did detection).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds audio attachment support for audio-capable models (Gemini), mirroring the existing image pipeline. Audio is off by default and must be explicitly opted in per-bot via
Confidence Score: 4/5Safe to merge — audio is off by default and the new paths are well-gated. The only rough edge is the in-memory audio cache having no eviction policy, which is a concern for long-running bots but not an immediate breakage. The feature is cleanly opt-in, the combined inline-data budget correctly accounts for both images and audio, and base64 redaction in logs was extended to cover the new media type. The src/discord/connector.ts — audio cache lifetime and the profiling log omission are both here.
|
| Filename | Overview |
|---|---|
| src/discord/connector.ts | Adds audio detection, pre-fetch size cap, MIME normalization, and in-memory caching for audio attachments. The audioCache has no eviction policy (mirrors imageCache). Profiling log omits audioCount. |
| src/context/stages/format-messages.ts | Promotes totalBase64Size to function scope so the combined images+audio budget (18 MB) can be enforced together. Audio emission is gated behind include_audio, bounded by max_audio, and checked against the combined ceiling. Logic is sound. |
| src/llm/membrane/adapter.ts | Adds audio case to toMembraneContentBlock, renaming media_type → mediaType and passing through duration. Simple and correct. |
| src/llm/membrane/provider.ts | Extends the base64-redaction log path to cover audio blocks alongside image, preventing large audio payloads from appearing in logs. |
| src/agent/loop.ts | Adds maxAudioFetch computation that gates audio fetching behind include_audio; max_audio ?? 1 handles the default correctly and max_audio: 0 disables audio consistently. |
| src/types.ts | Adds AudioContent, CachedAudio, and BotConfig audio config fields. Straightforward type additions with no issues. |
| src/llm/membrane/adapter.test.ts | Adds unit tests for audio block conversion (media_type → mediaType rename and duration passthrough). Coverage is appropriate for the new adapter case. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Discord
participant Connector as connector.ts
participant AudioCache as audioCache (in-memory)
participant Builder as context/builder.ts
participant Format as format-messages.ts
participant Adapter as membrane/adapter.ts
participant Gemini
Discord->>Connector: Message with audio attachment
Connector->>Connector: isAudioAttachment() — content_type or extension
Connector->>Connector: "Pre-fetch size check (attachment.size > 12 MB → skip)"
Connector->>Connector: audioMimeFor() — normalize MIME
Connector->>AudioCache: cacheAudio(url, mediaType, messageId, duration)
AudioCache->>Connector: CachedAudio (Buffer + metadata)
Connector->>Builder: "DiscordContext { audios: CachedAudio[] }"
Builder->>Format: formatMessages(..., audios)
Format->>Format: Build audioMap (url → CachedAudio)
Format->>Format: "Check include_audio + audioEmitted < maxAudio"
Format->>Format: Check combined inline budget (images + audio ≤ 18 MB)
Format->>Adapter: AudioContent block (base64 + media_type)
Adapter->>Gemini: "{ type: audio, source: { type: base64, mediaType, data } }"
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Discord
participant Connector as connector.ts
participant AudioCache as audioCache (in-memory)
participant Builder as context/builder.ts
participant Format as format-messages.ts
participant Adapter as membrane/adapter.ts
participant Gemini
Discord->>Connector: Message with audio attachment
Connector->>Connector: isAudioAttachment() — content_type or extension
Connector->>Connector: "Pre-fetch size check (attachment.size > 12 MB → skip)"
Connector->>Connector: audioMimeFor() — normalize MIME
Connector->>AudioCache: cacheAudio(url, mediaType, messageId, duration)
AudioCache->>Connector: CachedAudio (Buffer + metadata)
Connector->>Builder: "DiscordContext { audios: CachedAudio[] }"
Builder->>Format: formatMessages(..., audios)
Format->>Format: Build audioMap (url → CachedAudio)
Format->>Format: "Check include_audio + audioEmitted < maxAudio"
Format->>Format: Check combined inline budget (images + audio ≤ 18 MB)
Format->>Adapter: AudioContent block (base64 + media_type)
Adapter->>Gemini: "{ type: audio, source: { type: base64, mediaType, data } }"
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
src/discord/connector.ts:111
**Unbounded in-memory audio cache**
`audioCache` is a `Map` that grows without eviction for the life of the connector instance. Unlike `imageCache`, which has a disk persistence layer and URL map as a backing store, audio lives purely in RAM. Each entry can be up to 12 MB; a busy bot processing many distinct audio URLs across a long session can accumulate significant heap pressure. The image cache has the same pattern, but images are typically re-encountered across turns (cache hits) whereas audio files sent once are rarely re-sent, meaning the hit rate for audio may be lower and the unique-URL accumulation rate higher.
### Issue 2 of 2
src/discord/connector.ts:907-914
**Missing `audioCount` in the profiling and attachment-completion logs**
The `⏱️ PROFILING` log records `imageCount` and `documentCount` but not `audioCount`. When audio is silently dropped or not fetched, this makes the log entry misleading — a bot with `include_audio: true` shows no sign in the profiling line of whether audio was actually collected.
```suggestion
// Log fetch timings
logger.info({
...timings,
messageCount: discordMessages.length,
imageCount: images.length,
documentCount: documents.length,
audioCount: audios.length,
pinnedCount: pinnedConfigs.length,
}, '⏱️ PROFILING: fetchContext breakdown (ms)')
```
Reviews (1): Last reviewed commit: "fix(audio): emit extension-detected audi..." | Re-trigger Greptile
| } | ||
|
|
||
| export class DiscordConnector { | ||
| private client: Client |
There was a problem hiding this comment.
Unbounded in-memory audio cache
audioCache is a Map that grows without eviction for the life of the connector instance. Unlike imageCache, which has a disk persistence layer and URL map as a backing store, audio lives purely in RAM. Each entry can be up to 12 MB; a busy bot processing many distinct audio URLs across a long session can accumulate significant heap pressure. The image cache has the same pattern, but images are typically re-encountered across turns (cache hits) whereas audio files sent once are rarely re-sent, meaning the hit rate for audio may be lower and the unique-URL accumulation rate higher.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/discord/connector.ts
Line: 111
Comment:
**Unbounded in-memory audio cache**
`audioCache` is a `Map` that grows without eviction for the life of the connector instance. Unlike `imageCache`, which has a disk persistence layer and URL map as a backing store, audio lives purely in RAM. Each entry can be up to 12 MB; a busy bot processing many distinct audio URLs across a long session can accumulate significant heap pressure. The image cache has the same pattern, but images are typically re-encountered across turns (cache hits) whereas audio files sent once are rarely re-sent, meaning the hit rate for audio may be lower and the unique-URL accumulation rate higher.
How can I resolve this? If you propose a fix, please make it concise.Addresses two PR review findings: - audioCache had no eviction (unlike imageCache it has no disk backing, and audio is rarely re-sent so hit rate is low). Add an LRU byte budget (AUDIO_CACHE_MAX_BYTES, 64MB): cache hits refresh recency, evictAudioCache() drops oldest entries once over budget — still dedups audio in the rolling window. - add audioCount/totalAudios to the fetchContext profiling + completion logs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks Greptile — both addressed in
Good catches — the audio cache distinction (lower hit rate, no disk backing, larger entries) was a fair call. |
…-mpeg-3 Discord reports mp3 attachments as "audio/mpeg3" in the wild; the alias passed through un-normalized and downstream providers (OpenRouter) rightly refuse to guess a format for it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
✅ Acceptance-tested (2026-07-04)Verified live on a Discord server, together with antra-tess/membrane#29:
Found during testing and fixed in Gemini 3.5 Flash: MiMo V2.5 (OpenRouter): 🤖 Generated with Claude Code |
…e cap
Oversized audio was skipped with only a server-side log line, so users got a
misleading text-only answer ("I can't hear anything") with no visible reason.
React to the message instead (default 🐘, oversized_audio_emote: '' disables),
mirroring the 🚫 blocked-message reaction pattern; deduped per message so
rolling-window rescans don't re-react. Also document audio payload sizing:
per-file cap × max_audio ships in one request, so max_audio: 1 is the safe
default while the per-file cap is ~12-15 MB.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up: size-cap UX + payload sizing docs (
|


What
Routes Discord
audio/*attachments to audio-capable models, mirroring the existing image pipeline. Gated behind a new per-bot config —include_audio(default off) andmax_audio(default 1) — so audio only ever flows to a model the operator has explicitly opted in. The intended target is Gemini 3.5 Flash (trained on music). Not all models accept audio — and music understanding is narrower still — so this is a deliberate per-bot opt-in, never auto-routed.How
connector.ts): detectaudio/*attachments bycontent_typeor filename extension (Discord'scontent_typeis optional), fetch + in-memory cache, reject oversized uploads before downloading (MAX_AUDIO_BYTES, 12 MB), normalize MIME to a Gemini-accepted type, capture voice-message duration.AudioContentblock,CachedAudio,DiscordContext.audios,BotConfig.include_audio/max_audio.format-messages.ts): emitAudioContentblocks gated byinclude_audio, bounded by a combined images+audio inline budget (~18 MB, under Gemini's ~20 MB inline-data ceiling).AudioContent→ membrane audio block.provider.ts): base64 audio is redacted from request logs, same as images.loop.ts): only audio-capable bots fetch audio (maxAudiothreading;max_audio: 0disables).config/bots/claude.yaml.exampledocuments the new options.Dependency
Requires Gemini audio support in the membrane middleware — antra-tess/membrane#29. Until this repo's
@animalabs/membranedep is bumped to a release containing that, audio blocks are dropped harmlessly by the provider (and the feature is off by default regardless).Verification
gemini-2.5-flash; the path is model-agnostic and targets Gemini 3.5 Flash): a two-tone test clip was correctly described.tscclean.max_audio: 0consistency, and an extension-fallback emission gap.🤖 Generated with Claude Code